Титановый отвар
Функциональный Python. Не надо
23/03/2020

Есть словарь. Ключи в нём -- пути к файлам, а значения -- списки строк. Строки эти я получаю из некоего API, и они содержат подробные текстовые описания встретившихся в процессе работы над файлом проблем. По одной строке на проблему. Например:

{
'file://a/b/c/d.txt': [
'cluster0.engine1.check9: a problem is here: too long preface',
'cluster0.engine1.check2: absence of very useful thing',
'cluster0.engine2.fixed8: fixed presence of jabberwocky'
],
'file://a/b/c/x.txt': [
'cluster0.engine3.check4: oh, very boring error found',
'cluster0.engine2.check2: absence of very useful thing'
]
}

Задача -- определить наличие каждой ошибки и зарегистрировать её в базе данных. В общем-то, задача в этом виде сводится к поиску подстроки, идентифицирующей ошибку, в строках из списка с ошибками. Можно многое оптимизировать, но я бы хотел остановиться именно на таком варианте задачи. Он очень иллюстративен. Исходники "для поиграться" можно взять на гитхабе.

Императивный подход

def loop(error, list_of_errors):
for error_message in list_of_errors:
if error in error_message:
return True
return False

Максимально тупо и просто: на входе искомая подстрока и список строк с ошибками. Перебираем строки, если подстрока есть в строке -- ошибка найдена, возвращаем True. В конце концов, если не нашли -- возвращаем False.

Работает, но выглядит как-то некрасиво. Читать сложно, угол этот дурацкий... Попробуем иначе!

Функциональный подход (ленивый)

Ну насколько ленивый... Насколько ленивы питоньи генераторы.

def any_gen(error, list_of_errors):
return any(
True if error in error_message else False
for error_message in list_of_errors
)

Логика простая: создаётся генератор, отдающий True или False в зависимости от того, встретилась искомая подстрока в строке или нет. И к этому генератору применяется функция any. По факту, она распихивает между переданными ей параметрами логическое OR и отдаёт результат. Так что если хоть одно совпадение было, мы получим True. Код красивый, отлично читается, никаких "углов". Просто блок логики.

Функциональный подход (энергичный)

А тут мы просто генератор заменим на список. Пусть он все параметры для any вычислит сразу:

def any_list(error, list_of_errors):
return any([
True if error in x else False
for x in list_of_errors
])

Функциональный подход (свёртка)

from functools import reduce
def foldl(error, list_of_errors):
return reduce(
lambda prev_status, error_message: prev_status or error in error_message,
list_of_errors,
False
)

Вот тут уже интереснее. reduce выполняет левую свёртку над списком, принимая в качестве исходного значения False. Сворачивающая функция вернёт True только если искомая подстрока будет найдена в строке (выражение error in error_message истинно). Если такое случится -- результатом выполнения reduce будет True. Если нет -- будет возвращено исходное False. Выглядит красиво, но есть проблемка -- не все понимают, как работает reduce.

А теперь посмотрим, насколько быстро вся эта красота работает. Я беру в качестве исходных данных сто разных слов из какого-то wordlist'а и формирую из них случайным образом список строк, в котором буду искать сочетание the:

test = words.split()
random_index_of_list = lambda x: random.randrange(len(x))
list_items = 20
test_list_length = 100
test_list = [
[
test[random_index_of_list(test)]
for _ in range(list_items)
]
for _ in range(test_list_length)
]
substring = 'the'

Скорость будем измерять стандартным способом: timeit.

print('reduce\t', timeit.timeit(
'[foldl(substring, i) for i in test_list]',
setup='from __main__ import test_list, substring, foldl',
number=10000
))
print('any_gen\t', timeit.timeit(
'[any_gen(substring, i) for i in test_list]',
setup='from __main__ import test_list, substring, any_gen',
number=10000
))
print('any_lst\t', timeit.timeit(
'[any_list(substring, i) for i in test_list]',
setup='from __main__ import test_list, substring, any_list',
number=10000
))
print('loop\t', timeit.timeit(
'[loop(substring, i) for i in test_list]',
setup='from __main__ import test_list, substring, loop',
number=10000
))

Результат вполне предсказуем для императивного языка: простой цикл выигрывает с ощутимым отрывом.

reduce	 4.6457402000000005
any_gen	 3.540921899999999
any_lst	 3.1851106
loop	   1.8013910000000006

Почему так?

Дорогие функции в питоне

Вызов функции -- штука недешёвая. Нужно создать контекст выполнения, переключиться в него, передать параметры, получить их назад. Именно этим занимается вариант foldl(). На каждый элемент списка! Поэтому так долго.

Генераторы "дешевле" функций

По сравнению с вызовом функции для каждого элемента списка, генератор экономит на контексте. Однажды создав контекст, генератор возвращается к нему посчитать и выдать результат командой yield.

Списки жрут память

Результат не будет вычислен до тех пор, пока в памяти не построится весь список. Если обрабатываемые данные -- это 5 списков по 1000 элементов в каждом, генераторный вариант оказывается в 6 раз быстрее списочного за счёт лени: вычисление прерывается после первого совпадения (когда генератор вернёт True). Однако, на других данных списки могут оказаться быстрее: например, если на вход дать тысячу списков длиной по 5 элементов каждый. Сказывается оверхед на переключении контекстов между генератором и основным контекстом программы.

Наконец, императивный вариант

Лучше всего ложится на архитектуру языка. Питонья виртуальная машина тупо выполняет байткод без переключений между контекстами до тех пор, пока не обнаружит совпадение или не доберётся до конца списка.

Как жить дальше

В преимущественно императивном языке, по-моему, нужно пользоваться именно императивным подходом. Заигрывания с функциональной парадигмой без хорошей поддержки со стороны языка имеют неудовлетворительную производительность.

В случае питона можно пользоваться вариантами с функциями типа sum, any, all -- они хорошо читаются, структура программы чётче. Но боже упаси тащить в продакшн чисто функциональные штуки вроде монад или паттерн-матчинга. Даже если они существенно упрощают код. Потому что, во-первых, его будет тяжело поддерживать и читать, а во-вторых... Если без этих штук действительно тяжело -- почему бы не попробовать писать уже на нормальном функциональном языке?